網路層 HTTP response body
↓
1. input byte stream(位元組流) ← 網路層,還不是文字
↓
2. decoding(解碼)→ input stream ┐
↓ │
3. tokenization(標記化)→ tokens ├─ 這三步合起來 = HTML Parsing
↓ │
4. tree construction(樹建構) ┘
↓
DOM 樹(產物,不是第 5 步)
| # | 問題 | 一句話速答 | 章節 |
|---|---|---|---|
| Q1 | 網路上流的 bytes 是什麼?HTML 不是純文字嗎? | 兩者不衝突。「純文字」講的是內容語意,傳輸時一律是 bytes | §1 |
| Q2 | HTML 也有 tokenization?那不是 JS 的東西嗎? | HTML、CSS、JS 各有一套獨立的 tokenizer,執行者分別是 Blink、Blink、V8 | §3 |
| Q3 | HTML Parsing 是指哪幾步? | 步驟 2+3+4(decode、tokenize、tree construction)。DOM 是產物不是步驟 | §0 |
| Q4 | "initial" "in head" 那些字串是什麼? |
insertion mode(插入模式),樹建構狀態機的狀態名稱,規範共 21 個 | §4 |
| Q5 | 字元流跟字串流一樣嗎? | 不一樣。規範叫 input stream,裝的是 code points,可中途變長、不能回頭索引 | §2 |
| Q6 | <meta charset> 在 bytes 裡面,怎麼先讀到? |
encoding sniffing algorithm:BOM > 使用者 > HTTP header > prescan 前 1024 bytes | §2 |
| Q7 | DOM 節點就是 JS 物件嗎? | 半對。在 JS 側像 JS 物件,實體是引擎的 C++ 物件,中間隔一層 WebIDL 包裝 | §5 |
| Q8 | extends 是誰繼承誰? |
子 extends 父。HTMLDivElement extends HTMLElement = Div 是子 |
§5 |
| Q9 | CSR / SSR / PHP / Rails 的解析流程有差嗎? | 四步完全一樣。差別在「四步跑完畫面上有沒有東西」 | §6 |
| Q10 | Markdown 變 DOM 一定要先轉 HTML 字串嗎? | 有例外。react-markdown 全程不產生 HTML 字串 | §7 |
| 說法 | 對錯 | 精確講法 |
|---|---|---|
| HTML 是純文字格式 | ✅ | 指內容語意,不是儲存或傳輸形式 |
| HTML 傳輸時不是 bytes | ❌ | 一定是 bytes |
| 拿到 response body 就是字串了 | ❌ | 拿到的是 byte stream,還要走步驟 2 |
<!DOCTYPE html> 在網路上的真面目:
字元: < ! D O C T Y P E (空白) h t m l >
bytes: 3C 21 44 4F 43 54 59 50 45 20 68 74 6D 6C 3E
Content-Length: 1834,這個數字的單位是 bytes 不是字數。中文一字在 UTF-8 佔 3 bytes,所以中文網頁的 bytes 數遠大於字數。| 比較項 | 字串 String | 流 Stream |
|---|---|---|
| 長度 | 有 .length,一開始就知道 |
不知道,可能還在下載 |
| 存取 | 可隨機索引、可回頭 | 只能依序消耗,讀過就過去 |
| 中途變長 | 不行 | 可以(網路 chunk、document.write()) |
| 存在哪 | 完整存在記憶體 | 邊來邊處理 |
renderToPipeableStream)能分段送 HTML,也是吃這個性質。<meta charset> 寫在 bytes 裡面,要讀它得先解碼,要解碼得先知道 charset —— 規範的解法是這個優先序:
| 順序 | 依據 | 說明 |
|---|---|---|
| 1 | BOM(Byte Order Mark) | UTF-8 是 EF BB BF。有就直接採信 |
| 2 | 使用者手動指定 | 瀏覽器選單強制指定 |
| 3 | HTTP header | Content-Type: text/html; charset=utf-8。優先於 meta |
| 4 | prescan 前 1024 bytes | 找 <meta charset>。這就是它必須放 head 最前面的原因 |
| 5 | 父文件 | iframe 同源時繼承 |
| 6 | 歷史紀錄 | 之前造訪偵測到的 |
| 7 | 自動偵測 | 統計特徵猜(規範不鼓勵) |
| 8 | 語系預設 | 猜錯就是亂碼 mojibake,「中文」變 䏿–‡ |
E4 B8 AD;誤用 Latin-1 解讀會把 3 個 bytes 各當一個字 → ä¸。bytes 沒錯,錯的是那張解碼表。
| 語言 | 誰執行 tokenize | token 種類 | 下一步產出 |
|---|---|---|---|
| HTML | Blink(渲染引擎) | DOCTYPE / start tag / end tag / comment / character / EOF,共 6 種 | DOM 樹 |
| CSS | Blink 的 CSS parser | ident / hash / string / dimension / delim … | CSSOM 樹 |
| JavaScript | V8(JS 引擎) | keyword / identifier / operator / literal / punctuator … | AST → bytecode |
data state、tag open state、attribute name state…),只管切字元、不管樹長什麼樣。<div id="root">Hi</div> 的 token 序列:
① StartTagToken { name: "div", attributes: { id: "root" } }
② CharacterToken { data: "H" }
③ CharacterToken { data: "i" }
④ EndTagToken { name: "div" }
.style、沒有 .addEventListener。"initial" "before html" "in head" "in body" 叫 insertion mode(插入模式)。它不是 HTML 標籤、不是任何程式語言的語法,而是 WHATWG 規範文件裡的英文術語,代表樹建構狀態機的狀態。瀏覽器實作時變成 enum,例如 Blink 的 kInHeadMode。規範共 21 個:
initial before html before head in head
in head noscript after head in body text
in table in table text in caption in column group
in table body in row in cell in template
after body in frameset after frameset after after body
after after frameset
<td> 在 in row 是合法儲存格,在 in body 就是錯放、規範明文要求忽略它。HTML「寫錯也不會白屏」正是因為每個 insertion mode 都寫好了補救規則 —— 這是與 XML 最大的差別。| 層次 | 真相 |
|---|---|
你拿到的 document.body |
✅ 是 JS 物件,有原型鏈、可 . 取屬性 |
| 資料實際住在哪 | ❌ 不在 JS 堆積。真正的節點是引擎的 C++ 物件(Chrome 是 Blink) |
| JS 拿到的是什麼 | 透過 WebIDL binding 產生的包裝物件(platform object/宿主物件) |
驗證:Object.keys(document.body) 回傳 [],document.body.hasOwnProperty('tagName') 是 false —— 屬性全是 prototype 上的 getter,背後轉呼叫 C++。
extends 是誰繼承誰class 子 extends 父。左邊是子類,右邊是父類。
| 寫法 | 子(繼承者) | 父(被繼承者) | 白話 |
|---|---|---|---|
HTMLDivElement extends HTMLElement |
HTMLDivElement | HTMLElement | div 是一種 HTML 元素 |
HTMLElement extends Element |
HTMLElement | Element | HTML 元素是一種元素(SVG 元素也是) |
Element extends Node |
Element | Node | 元素是一種節點(文字、註解節點也是) |
Node extends EventTarget |
Node | EventTarget | 節點是一種可接收事件的東西(window、XHR 也是) |
三個記法:
(r-1) is-a 造句法:A extends B 讀成「A is a B」。「div is a HTMLElement」通順 ✅,反過來不通 ❌。
(r-2) 範圍大小法:父的範圍一定比子大。EventTarget > Node > Element > HTMLElement > HTMLDivElement。
(r-3) 查找方向法:JS 找屬性是從子往父往上找。在 div 上呼叫 addEventListener,一路找到 EventTarget.prototype 才找到。
(s) ★★★★★ addEventListener / removeEventListener / dispatchEvent 三個方法只定義在 EventTarget 這一層;style 在 HTMLElement;classList 在 Element;appendChild 在 Node。所以「可設寬高、可掛事件」是繼承來的能力,字串形態的 <div> 沒有這條鏈,什麼都做不了。
⚠️ 瀏覽器不是真的用 class ... extends ... 這段 JS 寫出來的;規範用的是 WebIDL 的 interface HTMLDivElement : HTMLElement,冒號左邊是子、右邊是父,與 extends 同義。
四步完全一樣,零差別。 差別只在三個變數:誰產生 HTML 字串、什麼時候產生、第一個 response 裡有沒有內容。
| 模式 | HTML 字串誰產生 | 何時 | 首個 response 有內容 | 四步 | 四步後還要做什麼 |
|---|---|---|---|---|---|
| 純靜態 HTML | 人手寫 | 寫程式時 | ✅ | 相同 | 直接 paint |
| SSG(Astro / Hugo / Jekyll / next export) | 建置工具 | build time 一次 | ✅ | 相同 | 有互動則需 hydration |
| SSR(Next.js / Nuxt / Remix) | Node 跑 renderToString |
每次 request | ✅ | 相同 | hydration 掛事件 |
| PHP / WordPress | PHP 直譯器 | 每次 request | ✅ | 相同 | 沒事了 |
| Rails / Django / Laravel | template 引擎(ERB / Jinja / Blade) | 每次 request | ✅ | 相同 | 沒事了 |
| CSR(Vite + React SPA) | 瀏覽器裡的 JS | HTML 解析完、JS 執行時 | ❌ #root 是空的 |
相同 | 還要跑一整套 JS 才有內容 |
| Streaming SSR(React 18) | Node 分段吐 | 每次 request,邊算邊送 | ✅(陸續到) | 相同 | 分段 hydration |
原本 [[script載入方式+前因後果]] (a) 節寫的「不是 CSR、SSR、SSG⋯⋯的專屬,他們全一樣,無例外」是對的,可再精確成:
「HTML Parsing 這四步無例外,但四步結束後畫面上有沒有東西,就完全不一樣了。」
CSR 的 HTML 解析反而更快(HTML 極短、節點極少)。它慢在四步跑完是白畫面,還要等 JS 下載 → V8 parse → 執行 → createElement 再建一次 DOM。瓶頸在 JS 不在 HTML Parsing。
一分鐘驗證法:Ctrl+U(伺服器原始字串)對比 F12 → Elements(現在的 DOM)。差很多且 #root 空 → CSR;幾乎一樣 → SSR/SSG/PHP/Rails。
innerHTML(要跑 HTML Parsing).md bytes → decode → Markdown 字串
→ Markdown 自己的 tokenizer → mdast
→ renderer 印成 ★HTML 字串★
→ el.innerHTML = htmlString
→ ★觸發 fragment parsing algorithm★(=本篇步驟 3、4 的片段版)
→ DOM
tokenize 兩次(Markdown 一次、HTML 一次)。
.md bytes → decode → Markdown 字串
→ remark → mdast → remark-rehype → hast(仍是 JS 物件)
→ rehype-react → React element
→ React 呼叫 document.createElement / appendChild
→ DOM
❌ 全程沒有 HTML 字串 ❌ 沒有 HTML Parsing
| 比較 | 路徑 A | 路徑 B |
|---|---|---|
| 中間有 HTML 字串 | 有 | 沒有 |
| 跑 HTML Parsing | 有(fragment parsing) | 沒有 |
| tokenize 次數 | 2 | 1 |
| XSS 風險 | 高,要接 DOMPurify | 低,天生擋掉大部分注入 |
| 建 DOM 的人 | Blink 的 HTML parser | React 呼叫 createElement |
⚠️ 要回頭修正 [[Markdown-渲染為DOM的過程]]:那篇的「Markdown 不能直接被渲染成 DOM,一定要先轉成 HTML 字串」對路徑 A 成立、對路徑 B 不成立。路徑 B 繞過 HTML 字串直接建 DOM,這正是 react-markdown 比 dangerouslySetInnerHTML 安全的原因 —— 沒有字串可以被注入。
<script> 如何打斷本篇的步驟 3、4。本篇是它的前傳。
線上版(GitHub Pages,Obsidian 之外的人可以直接看):https://abbychickenfillet-github.github.io/golang-and-TS-others-md-files/frontend-docs/html-basics/HTML-Parsing-瀏覽器拿到HTTP-response-body之後.html
<script> 六種載入模式index.html 是純文字,必須 Run-time 用 CPU 把它 Parse 成 DOM 樹一條主線,底下都是它的延伸:
index.html 丟過來,瀏覽器當下拿到的不是「網頁」,而是一整條純文字<!DOCTYPE html><html>…<div id="root">…</div>…。對電腦來說,<div> 一開始只是 5 個字元,不是「可設寬高、可掛事件的物件」。.md 也要先轉成 HTML 字串才有戲。)<html> 為根,掛 <head>/<body>,再掛 <div>/<p>…)。<div> 這段文字,就在記憶體 new 出一個 HTMLDivElement 物件(繼承鏈 HTMLDivElement→HTMLElement→Element→Node)。物件才有 .style、.className、.appendChild()、.addEventListener()。字串裡的 <div> 選不到也操作不動;變成物件、掛上 DOM 樹後,CSS 才選得到、JS 才操作得動。
| 解析前(純文字) | 解析後(記憶體物件) | 差別 |
|---|---|---|
<div> 這 5 個字元 |
一個 HTMLDivElement 物件 |
有 .style、.appendChild()、.addEventListener() |
<p>Hi</p> 這段字元 |
HTMLParagraphElement + 底下 Text("Hi") |
有父子關係,CSS 選得到、JS 操作得動 |
HTML 解析、DOM 建構、CSS 計算、畫面渲染、JS 執行,全部共用同一條主執行緒,同一瞬間只能跑一件事。所以主執行緒在跑 JS 時,HTML 解析得先停住;反之亦然。這就是後面所有載入策略要處理的核心矛盾:JS 執行會搶那條唯一的主執行緒,一搶,蓋房子(解析)就得暫停。
延伸(「把控制權交給 JS 引擎」是真的執行緒層級的交接):遇到會 blocking 的 <script> 時——① 解析器(在主執行緒)自己暫停;② 主執行緒轉交 V8,把 JS Parse 成 AST→編譯 bytecode→執行(下一步見 機器碼與bytecode的差異、00-V8引擎完整管線-Parse到Deoptimization);③ V8 跑完把主執行緒還給解析器,從暫停處繼續。注意:真正只有一條的是主執行緒;下載(fetch,網路 I/O)走獨立網路執行緒、不占主執行緒——所以「下載 JS」能跟「蓋房子」同時進行,會打架的永遠是「執行 JS」。
| 比喻 | 真實動作 | 占用資源 | 會不會卡畫面 |
|---|---|---|---|
| 🏠 蓋房子 | 解析 HTML、蓋 DOM 樹 | 主執行緒(唯一) | 這就是在畫骨架 |
| 📖 買說明書 | 下載 JS 檔(從網路抓 .js) |
網路執行緒(背景,不占主緒) | 不會,可邊蓋邊買 |
| 👓 看說明書 | 執行 JS(V8 跑起來、操作 DOM) | 主執行緒(唯一) | 會,一看就得停下蓋房子 |
三種策略的差別可翻成一句話:「買說明書」要不要打斷蓋房子?「看說明書」要在蓋房子的哪個時間點插進來?
一般 <script>=停在原地買+馬上看完再繼續;async=邊蓋邊背景買、書一到就停手看完;defer=邊蓋邊背景買、書先放著等整棟蓋完才看。
<script> 載入模式(並排關係,各佔一個字母)表格儲存格一律純文字(house rule:表格內不放 wikilink)。
「表格『蓋房子時下載不中斷』的主詞是各個 script 標籤?」——對。 每一列的主詞=那種<script>標籤/載入方式;「HTML 解析時」那欄=瀏覽器由上而下、解析到它那一刻會不會中斷。
| 模式 | 🏠 解析時(蓋房子) | 📖 下載(買說明書) | 👓 執行(看說明書) | 順序保證 | 典型場景/框架 |
|---|---|---|---|---|---|
<script>(一般,<head>) |
中斷(Blocking) | 讀到當下才抓,主緒空等 | 抓完立刻執行,才續解析 | 按出現順序 | 早期網頁;現代少用 |
<script async> |
下載不中斷;執行插隊中斷 | 背景平行下載 | 抓完立刻插隊執行 | 不保證 | GA、廣告、Next.js lazyOnload |
<script defer> |
不中斷 | 背景平行下載 | 等 DOM 蓋完才執行 | 保證按順序 | 需操作 DOM 的主程式;CRA/Webpack |
<script> 放 </body> 前 |
讀到時前面 DOM 幾乎蓋好 | 讀到才抓(本質仍一般) | 抓完立刻執行 | 按出現順序 | 傳統 SPA 自動注入位置 |
<script type="module"> |
不中斷(預設等同 defer) | 背景平行下載,遞迴抓 import | 等解析完才執行 | 保證 | Vite/原生 ES Module 主流 |
Ajax/fetch()/axios |
與載入解析無關 | JS 跑起來後主動抓「資料」 | 資料回來進 Event Loop | 由程式邏輯決定 | React useEffect、React Query/SWR |
關於這張表的主詞與「位置」:每一列的主詞=那種 <script> 標籤/載入方式;「HTML 解析時」欄=瀏覽器由上而下、解析到它那一刻會不會中斷。位置規則:async/defer 只對「有 src 的外部 script」有效(inline 加了沒用);慣例放 <head>(邊解析邊背景下載最划算),放 <body> 裡也行、但 defer 放 body 尾就沒意義;別放在 </body> 之後(body 外是不合規 HTML,瀏覽器會塞回 body)。
<script>(預設,<head>):中斷解析、立即執行、Blocking「站在原地買說明書+馬上看完」:解析器讀到就暫停,把主緒交給 V8 下載+執行,跑完才續。放 <head> 特別糟——<body> 都還沒蓋,JS 要找的 DOM 還不存在,使用者盯著白畫面等(parser-blocking)。
命名坑:別叫這個「同步 JS」——「同步/非同步 JS」在 JS 圈更常指程式碼本身的執行模型(callback/Promise/Event Loop,見 JavaScript-事件循環與閉包-面試核心),跟「載入時機」不同層次。本篇一律用 #阻塞解析(parser-blocking) 稱呼它。
<script async>:背景下載不中斷,但抓完立刻插隊執行、不保證順序背景網路執行緒平行下載(左側手寫補充:「利用背景執行緒達到非同步下載」),下載不打斷蓋房子;但一抓完就搶主緒執行,此刻 HTML 沒解析完就被打斷。適合跟 DOM、彼此都無依賴的獨立腳本(GA、廣告、lazyOnload)。
延伸(為什麼多個 async 不保證順序):同一份 HTML 寫 <script async src=a.js>、<script async src=b.js>,規則是「誰先抓完誰先跑」,不管寫的順序。若 b.js 較小先抓完,就 b 先 a 後,順序隨網路浮動、每次可能不同。若 b 依賴 a,用 async 會炸,要嘛改 defer、要嘛合併成一支。
<script defer>:背景下載不中斷、等 DOM 蓋完才依序執行、保證順序背景平行下載,但「書先放著」,延到整份 HTML 解析完(DOM 已蓋好)才依 HTML 順序執行。執行時 DOM 一定存在、多支又保證先後,最適合當專案主程式。CRA/Webpack 的主 bundle 常掛 defer。
<script> 放 </body> 前:土法煉鋼版 defer擺最後面,讀到它時前面 DOM 幾乎都蓋好,JS 一跑就操作得到元素。本質仍是會 blocking 的一般 script,只是位置在最後、影響小;沒有「背景平行下載」的好處(讀到才抓)。
<script type="module">:ES Module,預設就等於 deferVite/原生 ES Module 預設作法。特性:① 預設就是 defer 行為;② 依 import 遞迴抓相依模組、各模組只跑一次;③ 自帶嚴格模式與模組作用域(頂層變數不污染 window)。所以現代前端不用另加 defer。
fetch()/axios:不是載「程式」,是載「資料」跟上面五種不同層次:前五種是「如何載入並執行一支 .js」;Ajax 是「JS 已在跑之後主動去後端要資料(多半 JSON)」。由程式邏輯觸發(React useEffect 一掛載就發),回來的資料進 Event Loop 由 callback/await 處理,跟解析時機脫鉤。React Query/SWR 就是把它包成 hook。
本節 (d)–(i) 都發生在頁面第一次載入、瀏覽器跑開機流程時,使用者還沒互動。而 doubleClick/hover/發 API 是載入完、開始互動之後的事件驅動行為,由 Event Loop 排程(互動進 macrotask、Promise/fetch 回來進 microtask,見 事件循環-Event-Loop-微任務與巨任務)。兩者唯一關聯:載入方式決定「事件監聽器何時被註冊好」——監聽器所在的 script 還沒跑完前,點按鈕不會有反應。
① React 原始碼(.jsx/.tsx)
│ build-time:Babel/SWC 轉譯 + bundle(在開發者/CI 機器上,做一次)
▼
② 打包產物:index.html + 一堆 .js(都是純文字 Text)
│ 伺服器把 index.html 丟給瀏覽器
▼
③ 瀏覽器收到「一長串純文字字串」 …
│ ⚠️ 瀏覽器看不懂純文字(交給渲染引擎 Blink 的 HTML 解析器,不是 V8)
▼
④ HTML Parsing(run-time,用使用者 CPU,在主執行緒上,由 Blink 做)
▼
⑤ (文字) ──► HTMLDivElement(記憶體物件)→ 掛上 DOM 樹 → CSS 選得到、JS 操作得動
瀏覽器不認得 JSX/TS,所以 build-time 先轉譯打包成純文字(見 前端開發工具-打包編譯Lint與Parser);「變成可操作的東西」統統延到 run-time(HTML→DOM、JS→AST→bytecode)。同一份打包產物,被幾百萬使用者的 CPU 各自解析一次。
由誰跑? build 這步是「建置機器」跑的——你的筆電(本地 npm run build)或 CI 伺服器(GitHub Actions/Vercel)。不是使用者的瀏覽器;跑一次產出靜態檔,之後所有訪客拿到同一份。實際執行的人是 build tool(Vite/Webpack/Rollup)。
Vite 的兩種模式別搞混:
vite build(生產) 才做「建相依圖→bundle→最佳化」這整套,且底層叫 Rollup 做 bundle;vite dev(開發) 不做完整 bundle,改用 esbuild 預轉譯依賴+瀏覽器原生 ES Module 直接載入原始碼(所以啟動快)。vite dev的轉譯跟壓縮工具從esbuild/oxc正在往rolldown/oxc生態系遷移(複習到此時請重新查詢)
下面五步講的是生產 build。「打包」在此=bundle(步驟③)。
① 找入口、建相依圖(dependency graph)
從入口檔(webpack 預設 ./src/index.js;Vite 是 index.html 指的 main.tsx)開始,讀它的 import,再打開每個被 import 的檔、讀它們的 import,一路遞迴,把「誰 import 誰」畫成一張圖(節點=檔案/模組,連線=import;圖片/字型/CSS 被 import 也是節點)。這張圖就是「到底要打包哪些檔」的依據。官方:入口點告訴 bundler「從哪開始建內部相依圖」,再遞迴收齊所有相依。
main.tsx ──import──► App.tsx ──┬─import─► Header.tsx ─import─► logo.png
├─import─► Home.tsx ─import─► api.ts
└─import─► react(第三方庫)
💡 hint:寫好入口檔(index.js/main.tsx)很重要。 bundler 從入口檔開始、順著 import 建圖,沒被入口(直接或間接)import 到的檔,根本不會被打包。入口與 import 關係要寫對,東西才會被帶進去。怎麼寫見 如何寫入口檔-index-js-main-tsx。
② 逐模組轉譯(transpile,原始碼→原始碼,產物仍是純文字 JS)
TS 去型別: const n: number = 5; → const n = 5;
JSX→createElement:<div className="a">Hi {name}</div>
→ React.createElement("div", {className:"a"}, "Hi ", name)
(React 17+ automatic runtime 則是 _jsx("div", {...}))
新語法→目標語法: const y = a ?? b; → const y = a != null ? a : b;
各打包工具用什麼做 transpile(步驟②的實際執行者):
| 打包工具 | transpile 用誰 |
|---|---|
| Webpack | Loader:babel-loader/ts-loader/swc-loader/esbuild-loader(可選) |
| Vite | 預設 esbuild(Vite 8 起改 Oxc);React 用 @vitejs/plugin-react(Babel)或 plugin-react-swc(SWC) |
| Next.js | SWC(內建預設) |
| Rollup | plugin:@rollup/plugin-babel/-typescript/rollup-plugin-esbuild |
@rollup/plugin-typescript 有 dash:只是 npm 套件命名慣例(kebab-case 用連字號分詞);@rollup/plugin-typescript = scope @rollup 底下叫 plugin-typescript 的套件,無特殊意義。③ bundle 合併成 chunk(把幾百支原始模組 → 少數幾支輸出檔)
不是永遠變成 1 支——大方向是「大幅變少」,但 code splitting 會故意切成好幾支。三個子動作:
| 子動作 | 做什麼 | 方向 | 限制/備註 |
|---|---|---|---|
| tree-shaking | 砍掉沒被 import 用到的 export(死碼) | 減少 | 靠 ES module 靜態分析 |
| code splitting | 切成多個 chunk 按需載入(vendor/各 route lazy chunk) | 切開 | 靠動態 import/React.lazy |
| scope hoisting | 多個 ES 模組併進同一個函式作用域,省掉每模組包裹殼 | 合併 | 只對 ES module;靠改名避免衝突 |
import/export,標記出哪些 export 根本沒人用到。import()/React.lazy)。④ minify(壓縮)
誰做 minify:由
打包工具只是呼叫的壓縮器(minifier)」做,——
| 打包工具 | Webpack | Vite | Next.js |
|---|---|---|---|
| 其minifier | Terser | oxc(舊版esbuild) | SWC、Rollup靠@rollup/plugin-terser叫Terser |
-**** 預設叫 ****、
-Vite 新版 叫 oxc(舊版是 esbuild)、
-Next.js 叫 SWC、Rollup 靠 @rollup/plugin-terser 叫 Terser。
Terser 概念上的處理順序是:
parse(解析成語法樹)→ compress(做死碼消除、常數折疊等結構性優化)→ mangle(重新命名變數/屬性,是第 3 步) → generate(輸出最終字串,可選附上 source map)。
mangle 改名:把區域變數/函式名改成超短名(userName→a,只能改區域的才安全);再去空白+去註解(Terser 預設保留 @license/! 法律註解、其餘砍掉,也可設全砍)。Terser:空白移除+符號改名約佔壓縮量 95%。
@license/! 開頭的法律註解:
所有主流minifer(Terser, UglifyJS, esbuild)共同遵守的一個慣例
只要一個註解是以 @license 開頭或是以驚嘆號開頭例如/*! ... */就會被視為法律授權聲明而強制保留
預設雖然會把程式碼的註解清光
因為很多開源套件的授權條款(MIT License)明文要求只要散布這份程式碼就必須保留原始的著作權聲明
這是法律合規上的保護機制,不是技術限制。
function calculateTotal(price, qty) { // 加總
return price * qty;
} ↓ minify
function a(b,c){return b*c}
⑤ hash+產出+注入
輸出到 dist/,檔名帶內容 hash:main-a1b2c3.js(內容一變、檔名就變 → 舊快取自動失效=cache busting)。再把這些帶 hash 的檔名寫進 index.html 的 <script src>/<link href>(Vite 自動;webpack 用 html-webpack-plugin)。
一般用 webpack/Vite 手動設定的專案,預設輸出資料夾是 dist/。
Next.js 因為是框架,把整個建置產物(包含伺服器端跟客戶端兩份)都收在 .next/ 底下,這是 Next.js 自己的慣例路徑,概念上跟 dist/ 是同一種東西。
→ 對照 (q):「轉譯」是步驟 ②(廣義編譯,逐檔);「tree-shake/minify」是 ③④(bundle 子步驟);產物全都還是純文字 JS/HTML,不是機器碼。
先講重點:第①步「建相依圖」主要是 bundler 的「核心(core)」做的,不是某個 plugin;只有「解析 node_modules 路徑」那段靠 resolver(webpack 的 enhanced-resolve、Rollup 的 @rollup/plugin-node-resolve)。
| 步驟 | Webpack 老舊了啦 | Rollup | Vite(生產=Rollup) |
|---|---|---|---|
| ① 找入口、建相依圖 | 核心+解析器 enhanced-resolve+用 acorn 解析找 import | 核心+@rollup/plugin-node-resolve 解析 node_modules | Rollup 核心+Vite 解析 plugin;開發期依賴預打包用 esbuild |
| ② 逐模組轉譯 | Loaders(babel-loader/ts-loader/swc-loader/esbuild-loader) | transform 類 plugin(@rollup/plugin-babel/-typescript) | 預設 esbuild 轉 TS/JSX+@vitejs/plugin-react(Babel 或 SWC) |
| ③ bundle(tree-shake/code-split/scope-hoist) | 核心 bundling;code split=SplitChunksPlugin(內建);scope hoist=ModuleConcatenationPlugin(內建) | 全在核心啥中文啦(Rollup 首創 tree-shaking、預設 scope hoist;code split 靠動態 import/manualChunks) | 交給 Rollup 核心 |
| ④ minify | TerserWebpackPlugin(內建、production 預設)→ Terser | 不內建 → @rollup/plugin-terser → Terser | 預設 esbuild/新版 oxc/可選 terser |
| ⑤ hash+注入 index.html | 雜湊=核心 output [contenthash];注入 HTML=html-webpack-plugin | 雜湊=核心 [hash];注入 HTML=@rollup/plugin-html | Vite 原生處理 index.html+資源雜湊 |
Webpack 靠一堆內建 plugin+loaders 做;
Rollup 把 bundle 相關全放核心、轉譯和 minify 靠外掛;
Vite 生產底層用 Rollup,但轉譯/minify 自己選 esbuild/oxc。
補充(Q:Rollup 內建沒有 minify 嗎?)對,Rollup 核心不含壓縮器,要裝 @rollup/plugin-terser(官方 plugin)叫 Terser 做;這也是 Vite 要自己指定 minifier 的原因。
| 類別 | 由舊到新(出現年) | 2026 現況/地位 |
|---|---|---|
| bundler(打包器) | Browserify(2011)→Webpack(2012)→Rollup(2015)→Parcel(2017)→esbuild(2020)→Vite(2020)→Turbopack(2022)→Rspack(2023)→Rolldown(2026, Vite 8 內) | Vite=新專案預設(~25M/週);Webpack 下載仍最多(~30M)但少人選新專案(「era over」);Turbopack=Next.js 專用;Rspack=遷移 webpack 用;CRA 已棄用 |
| transpiler(轉譯器) | tsc(2012)→Babel(2014)→SWC(2019)→esbuild(2020)→Oxc(2023) | Babel 仍在但漸被 SWC/esbuild/Oxc(Rust/Go)取代 |
| minifier(壓縮器) | UglifyJS(2012, ES5 only)→Terser(2018)→esbuild/SWC(2020+)→Oxc(2023+) | Terser 仍是 webpack/rollup 標準;Vite 8 改用 Oxc |
(年份為近似出現/流行時間;Vite 8 於 2026-03 用 Rust 的 Rolldown 取代 esbuild+Rollup、並用 Oxc 做 TS/JSX 轉譯;細節見文末來源,屬彙整文章非官方。)
import()/React.lazy 切成獨立 chunk 檔;瀏覽器 在 runtime、使用者走到那頁時才去下載那支 chunk。配套但時間點/主詞不同,別混。require 不併)。分模組是「原始碼的組織(可維護)」、合併作用域是「輸出的最佳化」,行為一致。__webpack_require__)與動態 import 的 chunk 載入(在瀏覽器動態插 <script> 抓 lazy chunk)。所以「lazy loading 在 runtime」靠的就是這段被注入的 runtime code。主詞區分:工具本體只在 build-time;它產生的 runtime helper 在 runtime 繼續做載入。
__webpack_require__ 是什麼語言?是 JavaScript,webpack 產生的內部函式,用來取代你的 import/require。前後兩條底線 __ 只是「內部、別碰」的命名慣例,跟 Python 的 dunder(__init__)長得像但無關。HTML 在 build-time 的下場:建置工具只對模板 index.html 做文字層級處理——輕量 parse 以注入 <script>/<link> 標籤 + minify(去換行,所以產物常是連著一長條;但換行對 HTML 無語意,解析結果一樣)→ 產出一個 HTML 文字檔。build 期間沒有瀏覽器、沒有 DOM,所以沒有「HTML Parsing→DOM 樹」那個過程;真正的 HTML 解析只在 run-time、使用者瀏覽器裡發生。(SSG 例外:build-time 就先用 renderToString 把 HTML 字串產好,但一樣是產「字串」不是建 DOM。)
面試一句話:伺服器丟的 index.html 只是純文字,瀏覽器看不懂、不能直接操作,必須 run-time 用主執行緒(唯一)把 <div> 文字 Parse 成 HTMLDivElement、蓋成 DOM 樹;而 <script> 執行會搶那條主執行緒、打斷解析,才有 defer(等 DOM 蓋完再依序跑,適合主程式)、async(抓完插隊、不保證順序,適合 GA)、module(預設 defer)這些策略在喬「JS 何時搶主執行緒」。
上面 (k) 的流程圖畫的是 HTML→DOM 這條線,那條線上只有 Parse、沒有編譯(HTML 不是程式語言,不會被編譯)。「編譯」躲在另外兩個地方,而且三處嚴格講是三種不同的東西:
| # | 在哪 | 何時/誰 | 動作 | 嚴格名稱 | 產物 |
|---|---|---|---|---|---|
| 1 | 「打包」裡(build-time) | 開發者/CI 機器,Babel/SWC/tsc | JSX/TS → 純 JS | 轉譯 transpile(口語叫「編譯」但不精確) | 還是純文字 JS,不是機器碼 |
| 2 | V8(run-time) | 使用者開頁,Ignition 直譯器 | AST → bytecode | 編譯成 bytecode | bytecode |
| 3 | V8(run-time) | 熱點程式碼,TurboFan | bytecode → 機器碼 | JIT 編譯 | 機器碼 machine code |
關鍵一:流程圖裡的「打包」內含「轉譯」——那就是 build-time 的第一個「編譯」;但它的產物仍是純文字 JS,不是機器碼(前端 build 完不會變機器碼,別誤會)。
關鍵二:真正產生機器碼的編譯在 run-time 的 V8,而且它跟 HTML→DOM 是兩條平行的線,我 (k) 只畫了上面那條:
HTML 線: HTML 字串 ──(瀏覽器 HTML 解析器 Blink,不是 V8)──► DOM 樹 ← 只有 Parse,無編譯
JS 線: JS 字串 ──(V8 Parse)──► AST ──(編譯 Ignition)──► bytecode ──(JIT TurboFan)──► 機器碼 ← 編譯/機器碼在這
⚠️ 關鍵區分:HTML 那條由瀏覽器渲染引擎(Blink)的解析器做,完全不進 V8;AST/bytecode/機器碼只有 JS 那條有。 兩件事在主執行緒上是不同元件在跑。
兩條線的深入:DOM 那條見 Critical-Rendering-Path-關鍵渲染路徑-重排vs重繪;JS 那條見 機器碼與bytecode的差異、00-V8引擎完整管線-Parse到Deoptimization;打包/轉譯那側見 前端開發工具-打包編譯Lint與Parser。
第一個請求回的確實只有一份 index.html,但它裡面寫滿對 .css/.js/圖片的參照(<link href>、<script src>、<img src>);瀏覽器 Parse 到這些就再各發一個獨立請求去抓。所以「載入一個網頁」=先 1 份 HTML+接著並行抓十幾~幾十個檔(request waterfall)。第一個文件只有一份,但檔案總數永遠複數,這跟是不是 SPA 無關。
SPA 的 single 指「整個 app 只有一份 HTML 外殼,換頁(/about→/products)都用同一份、由 JS 動態抽換 #root 內容、不回伺服器要新 HTML」;它底下照樣掛一堆 .js/.css。
| 名稱 | 「頁」指的是 | HTML 文件數 | JS/CSS 檔數 | 換頁時 |
|---|---|---|---|---|
| SPA 單頁式 | 單一 HTML 外殼 | 通常 1 份 | 多個 | JS 前端重繪,不要新 HTML |
| MPA 多頁式 | 每條路由一份 HTML | 多份 | 多個 | 瀏覽器整份重載新 HTML |
現代打包會故意把 JS 切多個 chunk:vendor.js(第三方庫)、main.js(主程式)、各路由的 lazy chunk(用到才抓),讓首屏只下載必要部分。相關見 前端開發工具-打包編譯Lint與Parser。
任何網頁都要某個伺服器發檔,但「發檔」≠「渲染」:靜態檔案伺服器(nginx/CDN/S3/Vercel static)只把 build 好的檔原封不動發出、不運算 → 這是 CSR;執行中的應用伺服器(活著的 Node,Next.js/Remix/Nuxt)每請求現場跑 React 產 HTML → 這才是 SSR。SPA 一樣會丟 index.html:SPA+CSR 丟的是靜態伺服器發的空殼,SPA+SSR 丟的是伺服器渲染好的有內容 HTML。
⚠️ 別把 SPA 跟 CSR 畫等號:「SPA/MPA」是路由模型、「CSR/SSR」是首屏渲染在哪,兩個是獨立的軸,可自由組合(Next.js 就是 SSR+SPA)。完整拆解見 SPA架構-入口點-CSR客戶端效能與狀態-部署。
無框架沒有拿同一份元件邏輯重算一次因為這是React特有的設計,React元件本質上是用JS描述畫面長什麼樣子的函式。
CSR跟SSR並非沒有差異:
CSR只需針對瀏覽器打包出一份產物。整份程式碼從頭到尾都在瀏覽器執行,打包工具只要對著一個目標做tree-shaking, code-splitting, minify就結束了。
SSR則需要針對伺服器跟瀏覽器兩個不同執行環境,各自打包出一份不同的產物(分別對應.next/server與.next/static/chunks)
因為同一套元件邏輯要在兩種環境各跑一次⬅️跑什麼啦?
| 維度 | CSR | SSR |
|---|---|---|
| 伺服器角色 | 靜態檔案伺服器,只發檔 | 執行中的 Node,每請求產 HTML 字串 |
| index.html 內容 | 空殼 <div id="root"></div> |
已填好 <div id="root"><h1>…</h1></div> |
| 誰渲染首屏 | 瀏覽器端 JS(V8 跑 React 建 DOM) | 伺服器(Node 裡 V8 跑 renderToString 產字串) |
| 首屏 | 等 JS 下載+執行+抓資料後 | HTML 一到就有,JS 後補互動(hydration) |
| 代表 | CRA、Vite SPA(=SPA+CSR) | Next.js、Nuxt(=SPA+SSR) |
SSR 兩個常見誤解要澄清:
(1) 不是「每次 re-render 都在伺服器」——
只有首屏那一次在伺服器產 HTML 字串(Node 的 V8 跑 renderToString,注意產字串不是 DOM)
hydration 之後,互動造成的 re-render 回到瀏覽器端,跟 CSR 一樣。
(2) SSR 的動機:
首屏速度(FCP)+ SEO(爬蟲不跑 JS,直接給有內容的 HTML)+ 社群預覽(Open Graph meta 要在 HTML 裡)。
驗證小技巧:CSR 的「檢視原始碼(Ctrl+U)」幾乎空白、但「F12 Elements」滿滿元素——原始碼是伺服器發的空殼,Elements 是 JS 跑完的結果。呼應 (a):CSR 的 DOM 節點有兩個來源——① 瀏覽器 Parse 那份空殼(節點很少);② JS 用 document.createElement/React 在 run-time 現場建大部分節點塞進 #root。hydration 細節見 SSR-renderToString與Hydration-伺服器端渲染流程。更深入的 SPA 架構(入口點、客戶端效能、狀態存哪、CSR 用什麼伺服器發)見 SPA架構-入口點-CSR客戶端效能與狀態-部署。
Abby 原話:
「透過這一篇我了解到,框架對我們的幫助是什麼?可以幫我們處理打包工具(such as 找入口建相依圖、transpile、bundle、minify),並且處理好 script 載入的非同步順序,保證水合化。可是 Next.js 是 script async,這比較可惜吧?」
修正討論(三點):
(1) 框架 vs 打包工具:精確說,框架是把打包工具「配置好、指揮好」,實際做打包的還是 bundler——Next.js 底層用 Turbopack/Webpack、CRA 用 Webpack、Vite 系用 Vite。主詞:bundler 打包;框架=「包好 bundler +加上路由/SSR/資料抓取/script 策略」。
(2)「保證水合化」只在 SSR/SSG 框架:Next.js/Nuxt 這類才有 hydration;純 CSR(CRA、Vite SPA)沒有水合化(因為沒 SSR,見 SPA架構-入口點-CSR客戶端效能與狀態-部署)。所以這句要限定在 SSR 框架。
水合化(Hydration)是伺服器先把 React 元件算成純 HTML 字串送到瀏覽器,讓使用者第一時間就看到畫面內容(這階段沒有互動性,按鈕點了沒反應)。接著瀏覽器載入 React 的 JS,React 拿著同一份元件邏輯在瀏覽器裡「重新算一次」,然後不是整個重畫,而是接管已經存在的那份 HTML,把事件監聽器(onClick 之類)一個個掛上去——這個「接管」的動作就叫水合。名字取得很貼切:伺服器給的 HTML 是乾的骨架,水合就是把它「泡發」成活的、能互動的頁面。
水合成立的前提是:伺服器算出的 HTML,要跟瀏覽器重新算一次的結果長得一樣,React 才能安心地說「這就是我要的骨架,我直接接管就好,不用重畫」。這就是為什麼一旦兩邊算出來不一樣,React 就會報 hydration mismatch——它發現自己以為能直接接管的骨架,其實跟它自己算出來的不同。
(3)「Next.js 是 script async 比較可惜」→ 其實不可惜:Next.js 的 <Script> 策略(預設 afterInteractive、lazyOnload)是給第三方 script;Next 自己的框架 chunk 是精心編排的(beforeInteractive 給關鍵、主 bundle 有 manifest 保證順序)。就算 script 標籤帶 async,Next 的框架 runtime 也保證執行順序與 hydration 正確——等於「用 async 加速下載、又用框架 runtime 保證順序」,魚與熊掌兼得。
補充:Next.js 其實不只有 async——它用 <Script> 的 strategy 屬性讓你自由調載入時機,共 4 種策略(官方導引:https://nextjs.org/docs/app/guides/scripts )。對照你前面學的 script 概念:
Next strategy |
誰/何時載入 | 對應前面學的 | 優先級 |
|---|---|---|---|
| beforeInteractive | 注入初始 HTML(head)、先於 Next 核心、hydration 前 | 最像 <head> 裡的關鍵 script(要最早跑) |
最高 |
| afterInteractive(預設) | client 端注入、hydration 後盡快載第三方 | 像「互動後才動態 append 的 async script」,比 body 結尾更晚 | 高 |
| lazyOnload | 瀏覽器閒置、資源抓完才載低優先級 | 最延遲的 async(GA、廣告) | 低 |
| worker | 移到 Web Worker 執行,釋放主執行緒 | 呼應 (b)「主執行緒唯一」——換一條執行緒 | 正交(卸載) |
postMessage 與主執行緒溝通。Next 的 worker 策略把第三方 script 移進 Web Worker(底層用 Partytown,實驗性)。非 Next 管的 script 則自己加 async/defer。
beforeInteractive 保證多支順序(官方:executed in the order they are placed);afterInteractive/lazyOnload 都不保證多支順序——有相依關係要用 onLoad callback 串、或合併。afterInteractive 一定不在 hydration 之前完成(定義就是 hydration 後才載),比「body 尾的傳統 script」更晚。beforeInteractive「injected into the initial HTML」的意思:伺服器把這個 <script> 標籤寫進它送出的那份 HTML 字串裡(相對 afterInteractive 是 client 端才動態 append);不是「DOM 已 parse 好」。所以瀏覽器一拿到 HTML 就含它、parse 到 <head> 時很早處理、先於 Next 核心。⚠️ 註記:Gemini 分享連結需登入/JS 才渲染,這次抓不到完整對話,本篇以兩張圖為準,未從連結杜撰。module 預設 defer、fetch 走網路執行緒、DOM 繼承鏈等為既有正確知識補充。